Skip to content

feat: support multi-utterance persona messages - #71

Merged
sr-anam merged 4 commits into
mainfrom
feat/multi-utterance-messages
Aug 17, 2026
Merged

feat: support multi-utterance persona messages#71
sr-anam merged 4 commits into
mainfrom
feat/multi-utterance-messages

Conversation

@sr-anam

@sr-anam sr-anam commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

This is a purely additive change — the shape and behavior of the existing Message.content / MessageStreamEvent.content fields are unchanged. It adds a new, optional path for consumers who want to build a message as a set of utterances instead of one block of text.

  • Anam can split a single persona turn into multiple utterances, each tagged with an utterance_id. This adds support for exposing that breakdown so consumers can render each utterance as its own chat bubble instead of only ever seeing the whole turn as one block of text.
  • MessageStreamEvent gains an utterance_id: str | None field, populated per chunk when Anam sends one (empty/missing collapses to None, matching existing behavior for older responses).
  • Message gains an utterances: list[MessageUtterance] | None field — an ordered, per-utterance breakdown of the turn, built by merging consecutive chunks that share an utterance_id. Message.content is untouched by this and still holds the full concatenated turn text exactly as before.
  • MessageUtterance is a new small type: { id: str, content: str }.
  • Fully backward compatible: utterances stays None whenever Anam doesn't send utterance ids, so existing integrations that only read content see no change at all. It's also only ever populated on persona messages (never on user messages).

Test plan

  • pytest - full suite passes, including new tests covering: utterance id exposed on stream events, missing/empty utterance id collapsing to None, a turn splitting into multiple utterances while content stays a verbatim concatenation, and message shape staying unchanged when no utterance ids are sent.
  • ruff check - clean.
  • mypy - no new errors introduced.

🤖 Generated with Claude Code


Summary by cubic

Supports multi-utterance persona turns so clients can render each utterance as its own bubble. Previously persona turns were one concatenated message; now stream chunks include an utterance_id and the built Message exposes a per-utterance breakdown while keeping content unchanged.

  • Adds utterance_id to MessageStreamEvent; missing, empty, or non-string values collapse to None.
  • Introduces MessageUtterance and exports it; Message.utterances merges consecutive chunks by utterance_id, strips only the single joining space on non-first utterances while preserving other leading whitespace, and keeps content as the verbatim concatenation. Previously published Message snapshots are not mutated by later chunks.
  • Persona-only; user messages never populate utterances. Backward compatible with engines that omit or send invalid utterance ids. No migration required.

Written for commit dee8885. Summary will update on new commits.

Review in cubic

The engine can split a single persona turn into multiple utterances,
each tagged with an utterance_id. Message.content stays the full
concatenated turn text for backward compatibility; Message.utterances
now exposes an ordered, per-utterance breakdown so consumers can
render each utterance as its own chat bubble. MessageStreamEvent also
carries utterance_id per chunk. Both fields are omitted/None for
engines that don't send utterance ids, and utterances are persona-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTTaKSGows6cTfFitX1ck
Copilot AI lite review requested due to automatic review settings August 17, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for multi-utterance persona turns by exposing per-chunk utterance_id on stream events and a merged per-utterance breakdown on built persona Message objects, while keeping Message.content as the verbatim concatenation for backward compatibility.

Changes:

  • Introduces MessageUtterance and adds Message.utterances: list[MessageUtterance] | None for persona messages when utterance IDs are present.
  • Extends MessageStreamEvent with utterance_id: str | None and threads it through the client event pipeline.
  • Adds tests covering utterance-id passthrough, missing/empty collapse to None, utterance merging behavior, and unchanged shape when no utterance IDs are provided.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
tests/test_client.py Adds tests validating utterance_id passthrough and Message.utterances construction/compat behavior.
src/anam/types.py Adds MessageUtterance, extends Message and MessageStreamEvent to support utterance breakdown.
src/anam/client.py Parses utterance_id, emits it on stream events, and builds/updates per-message utterance breakdown.
src/anam/__init__.py Exports MessageUtterance as part of the public package surface.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/anam/client.py Outdated
Comment thread src/anam/client.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/anam/client.py Outdated
Comment thread src/anam/client.py
Comment thread src/anam/client.py Outdated
Comment thread src/anam/client.py
Mirrors _extract_correlation_id's type check so a non-string truthy
value from the backend can't violate MessageStreamEvent's str | None
contract.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTTaKSGows6cTfFitX1ck
@sr-anam
sr-anam requested a review from sebvanleuven August 17, 2026 09:40

@ziollek ziollek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dual-model review (Claude + Codex) — multi-utterance parity

Reviewed alongside javascript-sdk #228. Approving, with findings to address for true cross-SDK parity. These are new points, not a re-raise of the already-resolved lstrip thread.

Important — Python and JS now produce different utterances for identical engine input

The earlier lstrip comment was closed with the rationale that it "mirrors the JS SDK's appendUtterance, which calls content.trimStart() unconditionally." That rationale is now stale: JS #228 commit a1b80df ("preserve utterance leading whitespace") replaced trimStart() with single-separator stripping. Proven by running both implementations on the same input the JS harness uses — chunk1 ' Leading'/uuid-a, chunk2 ' Second'/uuid-b:

first utterance second utterance
JS (correct) ' Leading' (verbatim) ' Second' (one separator space removed)
Python (lstrip) 'Leading' (leading space lost) 'Second' (all leading whitespace lost)

Since the two SDKs are meant to be parity implementations, they should not diverge here. JS is the agreed behavior. Suggested fix in _append_utterance (src/anam/client.py:392):

content = event.content
if previous and content.startswith(" "):
    content = content[1:]
return previous + [MessageUtterance(id=event.utterance_id, content=content)]

Important — test masks the divergence

test_history_splits_utterances_keeps_turn_shape (tests/test_client.py) picks inputs where lstrip() and single-space-strip coincide (first utterance has no leading space; second has exactly one), so it green-lights the divergent behavior. When fixing the above, add the two cases that actually differ — a first utterance with a genuine leading space (preserved) and a later utterance with multiple leading spaces (exactly one removed) — mirroring JS's testHistoryPreservesNonSeparatorWhitespace.

Minor — per-chunk full-list rebuild (perf, both SDKs)

src/anam/client.py:391-392 rebuilds the whole utterances list on every chunk (previous[:-1] + [merged_last] / previous + [...]) → O(U·C) per turn. Negligible at realistic sizes and immutability is the existing pattern, so low priority; noted for parity with the same pattern in JS.

Verified safe / not reported: no concurrency or re-entrancy issue (_process_message_stream_event is synchronous with no await between read and write of _message_history); the role guard on _append_utterance is correct (it is the actual persona gate — not redundant); mixed-tag / mid-turn-drop behavior matches JS and was already discussed.

Comment thread src/anam/client.py Outdated
sr-anam and others added 2 commits August 17, 2026 14:12
Only the single joining space prepended before a subsequent utterance
is stripped; the first utterance in a turn, and any other leading
whitespace, is now preserved verbatim. Mirrors the JS SDK's a1b80df.

Also adds a regression test confirming a previously published Message
snapshot isn't mutated by later chunks for the same turn (the Python
port already builds fresh Message/utterance objects on every update,
so it never had the mutation bug the JS SDK introduced and fixed in
its own perf pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoTTaKSGows6cTfFitX1ck
@sr-anam
sr-anam merged commit 1e53e43 into main Aug 17, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants